Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

@W-16362973: [iOS] Add QR Code Login Support in MSDK #3759

Conversation

JohnsonEricAtSalesforce
Copy link
Contributor

@JohnsonEricAtSalesforce JohnsonEricAtSalesforce commented Sep 16, 2024

🎸 Ready For Review! 🥁

This adds the MSDK logic needed to support QR Code Log In to MSDK iOS, much like its Android counterpart.

This includes support for both web server flow and user agent flow in the QR code. Most of the Quip written by @wmathurin still applies, but be aware the APEX code has changed significantly for web server flow. I'll post the updated APEX here as well.

I added a really detailed code walkthrough in my self-review. I highly recommend reading it in detail this time, since this is my personal first foray into this tenured section of the authentication logic. Do share thoughts on the pattern I used for connecting the new parameters to and then using them in the existing authentication logic.

Be sure to see the follow-up pull request, W-16171422: [iOS] Update Native Login Sample App to include QR Code Login Flow.

Thanks for reading, as always.

@JohnsonEricAtSalesforce JohnsonEricAtSalesforce force-pushed the feature/w-16171402_ios-add-qr-code-login-support-in-msdk branch from 65f19ee to 38ba3b0 Compare September 20, 2024 18:38
@codecov-commenter
Copy link

Codecov Report

Attention: Patch coverage is 0% with 68 lines in your changes missing coverage. Please review.

Project coverage is 43.32%. Comparing base (b3b783a) to head (f5ea72c).

Files with missing lines Patch % Lines
...SDKCore/Classes/UserAccount/SFUserAccountManager.m 0.00% 37 Missing ⚠️
...e/SalesforceSDKCore/Classes/Util/SFSDKAuthHelper.m 0.00% 22 Missing ⚠️
...lesforceSDKCore/Classes/OAuth/SFOAuthCoordinator.m 0.00% 9 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##              dev    #3759      +/-   ##
==========================================
- Coverage   43.43%   43.32%   -0.11%     
==========================================
  Files         223      223              
  Lines       20587    20637      +50     
==========================================
  Hits         8941     8941              
- Misses      11646    11696      +50     
Flag Coverage Δ
MobileSync 38.90% <0.00%> (-0.10%) ⬇️
SmartStore 23.20% <0.00%> (-0.12%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
...forceSDKCore/Classes/Login/SFLoginViewController.m 0.00% <ø> (ø)
...lesforceSDKCore/Classes/OAuth/SFOAuthCoordinator.m 0.00% <0.00%> (ø)
...e/SalesforceSDKCore/Classes/Util/SFSDKAuthHelper.m 0.00% <0.00%> (ø)
...SDKCore/Classes/UserAccount/SFUserAccountManager.m 18.53% <0.00%> (-0.39%) ⬇️

@JohnsonEricAtSalesforce
Copy link
Contributor Author

Here's the APEX for user agent flow:

public class QRCodeLoginController {
    public String qrCodeOneTimeLoginHyperlink {get;set;}

    //
    // One Time Use Token Exchange
    //

    // Setup
    // 1. Create self signed cert called JWT_Bearer (see https://help.salesforce.com/s/articleView?id=sf.security_keys_creating.htm&language=en_US&type=5)
    // 2. Download the cert
    // 3. Create Connected App, capture its client id in jwtClientId
    // 4. Enable digital signatures, upload cert from step (2)
    // 4. Add web and refresh scope
    // 5. Set app to admin pre-approved
    private String jwtClientId = '3MVG9.AgwtoIvERSd8i8lePrqfs7CazRx2llbL8ubNoG6R3HsYomQFRpbayaMH4HtzH3zj0NDEmC0PIohw0Pf';
    private String selfSignedCertName = 'JWT_Bearer';
    private String instanceUrl = 'https://mobilesdkatsdb6.test1.my.pc-rnd.salesforce.com';
    private String tokenEndpoint = instanceUrl + '/services/oauth2/token';
    private String oneTimeTokenEndpoint = instanceUrl + '/services/oauth2/singleaccess';

    //
    // Mobile App Configuration
    //

    private String mobileClientId = '3MVG9.AgwtoIvERSd8i8lePrqfnKG_MM7P9KAJ4g53iaPA4EN8zUt3__o.8YA_hCeRn_kGR.Xe9I9_pnsFuAW';
    private String callbackURL = 'mobilesdk://android/pn/tester';
    private String mobileDeepLinkURL = 'mobileapp://android/login/qr';

    /**
     * Generate mobile sign in link
     *  The operation is asynchronous and the generated link is stored in qrCodeOneTimeLoginHyperlink
     */
    public PageReference generateQrCodeOneTimeLoginHyperlink() {

        String mobileStartURL = '/services/oauth2/authorize' + '?response_type=hybrid_token&client_id=' + encode(mobileClientId) + '&redirect_uri=' + encode(callbackURL);
        String bridgeUrl = this.generateBridgeUrl(mobileStartURL);
        Map<String, String> jsonMap = new Map<String, String>();
        jsonMap.put('frontdoor_bridge_url', bridgeUrl);
        String jsonString = JSON.serialize(jsonMap);
        String encodedJsonString = EncodingUtil.urlEncode(jsonString, 'UTF-8');

        this.qrCodeOneTimeLoginHyperlink = mobileDeepLinkURL + '?bridgeJson=' + encodedJsonString;
        return null;
    }

    private String generateBridgeUrl(String startURL) {
        String accessToken = getAccessToken();

        Http h = new Http();
        HttpRequest req = new HttpRequest();
        req.setMethod('POST');

        String url = oneTimeTokenEndpoint;
        req.setEndpoint(url);

        String body = 'redirect_uri=' + encode(startURL);
        req.setBody(body);

        //Add Headers
        req.setHeader('Content-Type','application/x-www-form-urlencoded');
        req.setHeader('Authorization','Bearer ' + accessToken);

        //Send Authorzation Request
        HttpResponse res = h.send(req);
        oneTimeUseResponse otur = (oneTimeUseResponse)JSON.deserialize(res.getBody(), oneTimeUseResponse.class);

        return otur.frontdoor_uri;
    }

    private String getAccessToken() {
        Auth.JWT jwt = new Auth.JWT();
        jwt.setSub(UserInfo.getUserName());
        jwt.setAud('https://login.test1.pc-rnd.salesforce.com');
        jwt.setIss(jwtClientId);

        //Additional claims to set scope
        Map<String, Object> claims = new Map<String, Object>();

        //Create the object that signs the JWT bearer token, hardcoded cert dev name for POC
        Auth.JWS jws = new Auth.JWS(jwt, selfSignedCertName);

        //POST the JWT bearer token
        Auth.JWTBearerTokenExchange bearer = new Auth.JWTBearerTokenExchange(tokenEndpoint, jws);
        String accessToken = bearer.getAccessToken();

        return accessToken;
    }

    private String encode(String value) {
        return EncodingUtil.urlEncode(value, 'UTF-8');
    }

    private class oneTimeUseResponse {
        public string frontdoor_uri;
    }
}

@JohnsonEricAtSalesforce
Copy link
Contributor Author

Here's the APEX for web server flow. Note, @wmathurin and I only have one page and controller in place. We've been toggling the APEX content as we switch between tests. We could scale that so it is more convenient.

//
// An APEX controller that prepares and generates a log in QR code using
// Salesforce Identity Single Access UI Bridge API and the hybrid web server
// flow.
//
// Setup:
// 1. Create a self-signed certificate named JWT_Bearer.  See https://help.salesforce.com/s/articleView?id=sf.security_keys_creating.htm&language=en_US&type=5
// 2. Download the certificate
// 3. Create a connected app and set `jwtClientId` to the connected app client id
// 4. Enable digital signatures and upload the certificate from step 2
// 4. Add web and refresh scopes
// 5. Set the app to admin pre-approved
//
// See https://help.salesforce.com/s/articleView?id=sf.frontdoor_singleaccess.htm&type=5
// See https://help.salesforce.com/s/articleView?id=sf.remoteaccess_oauth_hybrid_web_server_flow.htm&type=5
//
public class QRCodeLoginController {

    // The generated one-time-use hyperlink for the QR code payload.
    public String qrCodeOneTimeLoginHyperlink {get;set;}
    
    // The self-signed certificate name.
    private String selfSignedCertName = 'JWT_Bearer';

    // The connected app client id.
    private String jwtClientId = '3MVG9.AgwtoIvERSd8i8lePrqfs7CazRx2llbL8ubNoG6R3HsYomQFRpbayaMH4HtzH3zj0NDEmC0PIohw0Pf';

    // The Salesforce instance URL.
    private String instanceUrl = 'https://mobilesdkatsdb6.test1.my.pc-rnd.salesforce.com';

    // The Salesforce Identity API OAuth 2.0 token endpoint URL.
    private String oauth2TokenEndpoint = instanceUrl + '/services/oauth2/token';

    // The Salesforce Identity API OAuth 2.0 UI bridge single access endpoint URL.
    private String oauth2SingleAccessEndpoint = instanceUrl + '/services/oauth2/singleaccess';

    // The mobile app's client id.
    private String mobileClientId = '3MVG9.AgwtoIvERSd8i8lePrqfnKG_MM7P9KAJ4g53iaPA4EN8zUt3__o.8YA_hCeRn_kGR.Xe9I9_pnsFuAW';
    
    // The connected app's callback URL.
    private String callbackURL = 'mobilesdk://android/pn/tester';
    
    // The mobile app's deep link URL.
    private String mobileDeepLinkUrl = 'mobileapp://android/login/qr';

    /**
     * Generate the QR code's one-time-login hyperlink. The operation is
     * asynchronous and the generated hyperlink is stored in
     * `qrCodeOneTimeLoginHyperlink`.
     */
    public PageReference generateQrCodeOneTimeLoginHyperlink() {

        // Generate PKCE code verifier and code challenge.
        String codeVerifier = generateCodeVerifier();
        String codeChallenge = generateCodeChallenge(codeVerifier);
        
        // Generate the client start URL.
        String clientStartUrl = '/services/oauth2/authorize' + '?response_type=code&client_id=' + encode(mobileClientId) + '&redirect_uri=' + encode(callbackURL) + '&code_challenge=' + encode(codeChallenge);
        
        // Generate the UI Bridge API Front Door URL.
        String uiBridgeFrontDoorUrl = generateUiBridgeFrontDoorUrl(clientStartUrl);
        
        // Assemble the log in QR code's JSON payload.
        Map<String, String> jsonPayload = new Map<String, String>();
        jsonPayload.put('frontdoor_bridge_url', uiBridgeFrontDoorUrl);
        jsonPayload.put('pkce_code_verifier', codeVerifier);
        String jsonPayloadString = JSON.serialize(jsonPayload);
        String jsonPayloadUrlEncoded = EncodingUtil.urlEncode(jsonPayloadString, 'UTF-8');

        qrCodeOneTimeLoginHyperlink = mobileDeepLinkUrl + '?bridgeJson=' + jsonPayloadUrlEncoded;
        return null;
    }

    /*
     * Generates the UI Bridge API Front Door URL using the provided client
     * start URL.
     */
    private String generateUiBridgeFrontDoorUrl(String startUrl) {
        String accessToken = getAccessToken();

        Http http = new Http();
        HttpRequest request = new HttpRequest();
        request.setMethod('POST');

        String url = oauth2SingleAccessEndpoint;
        request.setEndpoint(url);

        String body = 'redirect_uri=' + encode(startUrl);
        request.setBody(body);

        request.setHeader('Content-Type','application/x-www-form-urlencoded');
        request.setHeader('Authorization','Bearer ' + accessToken);

        HttpResponse response = http.send(request);
        SingleAccessResponse singleAccessResponse = (SingleAccessResponse)JSON.deserialize(response.getBody(), SingleAccessResponse.class);

        return singleAccessResponse.frontdoor_uri;
    }

    /*
     * Gets the access token.
     */
    private String getAccessToken() {
        Auth.JWT jwt = new Auth.JWT();
        jwt.setSub(UserInfo.getUserName());
        jwt.setAud('https://login.test1.pc-rnd.salesforce.com');
        jwt.setIss(jwtClientId);

        // Additional claims to set scope
        Map<String, Object> claims = new Map<String, Object>();

        // Create the object that signs the JWT bearer token with a hardcoded certificate developer name for POC.
        Auth.JWS jws = new Auth.JWS(jwt, selfSignedCertName);

        // POST the JWT bearer token.
        Auth.JWTBearerTokenExchange bearer = new Auth.JWTBearerTokenExchange(oauth2TokenEndpoint, jws);
        String accessToken = bearer.getAccessToken();

        return accessToken;
    }

    /*
     * Generates a PKCE code verifier.
     */
    private String generateCodeVerifier() {
        // Code verifier set up.
        String codeVerifierCharacterSet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
        Integer codeVerifierLength = 128;

        // Generate code verifier string.
        String codeVerifier = '';
        for (Integer i = 0; i < codeVerifierLength; i++) {
            Integer index = Math.mod(Math.abs(Crypto.getRandomInteger()), codeVerifierCharacterSet.length());
            codeVerifier += codeVerifierCharacterSet.substring(index, index + 1);
        }

        // Encode code verifier string to Base64 spec.
        codeVerifier = EncodingUtil.base64Encode(Blob.valueOf(codeVerifier));

        // Encode code verifier Base64 to Base64 URL-safe spec.
        codeVerifier = codeVerifier.replace('+', '-').replace('/', '_').replace('=', '');

        return codeVerifier;
    }

    /*
     * Generates a PKCE code challenge from the provided code verifier.
     */
    private String generateCodeChallenge(String codeVerifier) {
        // Generate code challenge string from code verifier.
        Blob codeVerifierBlob = Blob.valueOf(codeVerifier);
        Blob codeChallenge256Blob = Crypto.generateDigest('SHA-256', codeVerifierBlob);

        // Encode code challenge string to Base64 spec.
        String codeChallengeBase64Encoded = EncodingUtil.base64Encode(codeChallenge256Blob);
        // Encode code challenge Base64 to Base64 URL-safe spec.
        String codeChallengeBase64UrlSafeEncoded = codeChallengeBase64Encoded.replace('+', '-').replace('/', '_').replace('=', '');

        return codeChallengeBase64UrlSafeEncoded;
    }

    /*
     * URL encodes a string. 
     */
    private String encode(String value) {
        return EncodingUtil.urlEncode(value, 'UTF-8');
    }

    /*
     * Encodes a given Base64 string to the Base64 URL-safe spec.
     */
    private String base64ToBase64UrlSafe(String base64Value) {
      return base64Value.replace('+', '-').replace('/', '_').replace('=', '');
    }

    /*
     * A class to model responses from the Salesforce Identity OAuth 2.0 UI bridge single access endpoint.
     */
    private class SingleAccessResponse {
        public string frontdoor_uri;
    }
}

@JohnsonEricAtSalesforce JohnsonEricAtSalesforce marked this pull request as ready for review September 26, 2024 21:46
Copy link
Contributor Author

@JohnsonEricAtSalesforce JohnsonEricAtSalesforce left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here's my self-review notes and code walk-though.

…r Bridge Parameters On Authorization Failure)
@JohnsonEricAtSalesforce
Copy link
Contributor Author

@bbirman and @wmathurin - I added a commit to match MSDK Android's addition of a UI Bridge Front Door URL reset on both authentication success and failure. That will aid users if they fail a QR code log in and the app gives them the option to return back to the web view or if they use a QR code with different parameters.

The app would need to stop the current authentication and re-call one of the auth helper log in methods as well if the user cancelled from the QR code log in.

@JohnsonEricAtSalesforce JohnsonEricAtSalesforce merged commit f0cbfca into forcedotcom:dev Oct 2, 2024
7 of 9 checks passed
@JohnsonEricAtSalesforce JohnsonEricAtSalesforce deleted the feature/w-16171402_ios-add-qr-code-login-support-in-msdk branch October 2, 2024 23:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants